Skip to content

Add WASM support: --wasm flag with Node.js and Deno runtimes#4176

Open
lostflydev wants to merge 8 commits into
VirtusLab:mainfrom
lostflydev:scala-wasm-support
Open

Add WASM support: --wasm flag with Node.js and Deno runtimes#4176
lostflydev wants to merge 8 commits into
VirtusLab:mainfrom
lostflydev:scala-wasm-support

Conversation

@lostflydev
Copy link
Copy Markdown

Implements scala-cli issue #3316: integrate WebAssembly with Scala CLI.

  • --wasm CLI flag and //> using wasm directive to enable WASM output

  • --wasm-runtime <runtime> option and //> using wasmRuntime directive Supported values: node (default), deno, wasmtime, wasmedge, wasmer

  • --deno-version, --wasmtime-version, --wasmer-version options and corresponding directives for pinning runtime versions

  • Node.js (default): runs Scala.js WASM output with --experimental-wasm-exnref flag, requires Node.js >= 22

  • Deno: runs Scala.js WASM output; if not found on PATH, downloads from GitHub releases via Coursier cache

  • Wasmtime / WasmEdge / Wasmer: return UnsupportedWasmRuntimeError pending upstream Scala.js standalone WASM support (Make Scala.js Wasm backend suitable for standalone Wasm VMs (a.k.a. support "server-side Wasm") scala-js/scala-js#4991)

@Gedochao Gedochao linked an issue Mar 11, 2026 that may be closed by this pull request
7 tasks
@Gedochao
Copy link
Copy Markdown
Contributor

@Gedochao
Copy link
Copy Markdown
Contributor

@lostflydev I will go over the review in the coming days (this is a hefty one, might take a bit), but in the meantime - it seems the reference doc hasn't been generated.
Refer to https://github.com/VirtusLab/scala-cli/blob/main/CONTRIBUTING.md#rules-for-a-well-formed-pr

Comment thread modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala Outdated
Comment thread modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala Outdated
Comment on lines +28 to +31
// Standalone runtimes (future - requires upstream Scala.js standalone WASM support)
case object Wasmtime extends WasmRuntime("wasmtime")
case object WasmEdge extends WasmRuntime("wasmedge")
case object Wasmer extends WasmRuntime("wasmer")
Copy link
Copy Markdown

@tanishiking tanishiking Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they don't work, I feel we don't need to add those options yet

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@Gedochao Gedochao marked this pull request as draft March 12, 2026 09:46
@Gedochao
Copy link
Copy Markdown
Contributor

(converted to a draft, as this clearly needs more work; feel free to change it back when it's ready to review)

@Gedochao
Copy link
Copy Markdown
Contributor

(I can see the post-review changes, but a rebase will be necessary to re-run the CI)

@lostflydev
Copy link
Copy Markdown
Author

(I can see the post-review changes, but a rebase will be necessary to re-run the CI)

Hi @Gedochao ! I`ve rebased onto actual main, could you pls re-run CI/CD

@lostflydev lostflydev force-pushed the scala-wasm-support branch 3 times, most recently from 3358e24 to 7f43349 Compare April 2, 2026 15:27
@lostflydev lostflydev marked this pull request as ready for review April 2, 2026 15:28
@He-Pin
Copy link
Copy Markdown

He-Pin commented Apr 6, 2026

why not use bun as a runtime

@lostflydev
Copy link
Copy Markdown
Author

why not use bun as a runtime

@He-Pin Thanks for comment, added bun as a runtime

@lostflydev lostflydev marked this pull request as draft April 28, 2026 06:35
@lostflydev lostflydev force-pushed the scala-wasm-support branch from 721bbcf to 714e11a Compare May 6, 2026 09:01
@lostflydev lostflydev marked this pull request as ready for review May 6, 2026 09:02
@lostflydev lostflydev force-pushed the scala-wasm-support branch from 714e11a to 077bf00 Compare May 11, 2026 08:16
@lostflydev
Copy link
Copy Markdown
Author

Hi @Gedochao, could you please re-run the failed native-windows-tests-default (https://github.com/VirtusLab/scala-cli/actions/runs/25658534589/job/75315398257?pr=4176#logs) job? I think it might be flaky
and not caused by the changes in this PR

@Gedochao
Copy link
Copy Markdown
Contributor

Yep, almost certainly flaky. I restarted it.
There's a bunch of flaky tests and GitHub is very unstable this week, ping me if anything else needs restarting.

@lostflydev
Copy link
Copy Markdown
Author

Hi @tanishiking @sjrd @Florian3k @lbialy @dos65 👋

Gentle ping — the PR is ready for another look. Addressed previous feedback: dropped runtime auto-download and unsupported standalone runtimes, added Bun support, rebased, fixed style and docs.
All required CI checks are green.

Whenever you have a spare moment, I'd really appreciate your feedback

Comment thread modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala Outdated
}
else if (emitWasm) {
// For WASM mode with ES modules, run node directly instead of NodeJSEnv.
// NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule.
Copy link
Copy Markdown

@tanishiking tanishiking May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it works with ESModule, and we don't need this else if (emitWasm) branch. What didn't work here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch is necessary, though the original comment was unclear about the reason. Here's what breaks without it:

The else path (NodeJSEnv) constructs nodeArgs as: nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList) The "-" is the stdin-pipe signal for Input.Script mode. When NodeJSEnv runs an Input.ESModule with non-empty args, the final Node command becomes: node --experimental-wasm-exnref - foo bar baz /tmp/scalajs-runner.cjs
Node sees "-" and reads from stdin, ignoring /tmp/scalajs-runner.cjs. Wasm always uses ESModule, so the program never runs when user args are present

The direct ProcessBuilder path produces the correct command: node --experimental-wasm-exnref /tmp/main.mjs foo bar baz
Updated the comment to accurately reflect this. This branch is covered by the "Wasm passes arguments to program" integration test

Copy link
Copy Markdown

@tanishiking tanishiking May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When NodeJSEnv runs an Input.ESModule with non-empty args, the final Node command becomes: node --experimental-wasm-exnref - foo bar baz /tmp/scalajs-runner.cjs

I don't think this is true, as commented below, NodeJSEnv "runs apps by piping JS to node" (write Input content to tmp file and piped JS dynamically imports that). So there shouldn't be something like /tmp/scalajs-runner.cjs on the final command line. (If that's true, scala-cli's ESModule Input should be broken).

This branch is covered by the "Wasm passes arguments to program" integration test

I see "Wasm passes arguments to program", and it passes without this branch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this conversation seems unresolved?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tanishiking This branch was removed in a previous commit. I recheck it carefully, you were right


// Detects the major version of Node.js on PATH; cached for the JVM lifetime (lazy val).
// Returns None if node is not found or version cannot be parsed.
private lazy val nodeMajorVersion: Option[Int] =
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about scala-cli policy, but I feel like detecting node version if it supports wasm or not is too much.

my 2 cents: scala-cli should loosely couple with the runtime environment, just try to run and let them fail if it's too old.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it, so scala cli lets runtime fail if it is too old

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see it's removed, did you forget to push some commits?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likewise, unresolved?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the earlier confusion — I said "removed" but hadn't actually pushed the change. In the end I kept the version detection, because @Gedochao's comment below explicitly approved the approach:
"We can add some options as implicit if user doesn't specify them and we know they are necessary to run the Wasm build. Just make sure the implicit stuff is logged."

The current state: nodeMajorVersion remains, --experimental-wasm-exnref is passed only on Node < 25 (where V8 12.x requires it), and when it is injected, Scala CLI now logs:
"Wasm: adding --experimental-wasm-exnref (required for Wasm exception handling on Node.js < 25)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(While I personally don't like scala-cli manage options implicitly), if we do that, we should properly append all necessary flags for GC and EH across every version of deno and Node. Why don't we automatically append options for Node 22, 23, and 24? and how about every versions of Deno?

Also, do I understand correctly that, we only manage the minimum required flags for execution (exnref, gc, function-references)? I mean, how about other features such as js-string-builtins, JSPI, (and custom descriptor in future?) Are those flags are expected to be added by users manually through NODE_OPTIONS and DENO_V8_FLAGS ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that sounds like quite the headache to manage implicitly...
to chip in - doing some of this for the user is fine, as long as it is clear what needs to be passed manually, and what has been picked up implicitly.
the feature is meant to be experimental, so it's okay to require some knowledge from the user (although if we can provide a seamless UX, that's great)

nodeMajorVersion.foreach { v =>
if (v < 22) value(Left(new NodeVersionTooOldForWasmError(v)))
}
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
Copy link
Copy Markdown

@tanishiking tanishiking May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think tools like scala-cli to hardcode Node options, and instead, let users explicitly specify Node options by themselves something like like: --node-args=--experimental-wasm-exnref,--experimental-wasm-imported-strings ?

in Node 26, options like --experimental-wasm-imported-strings is removed, and --experimental-wasm-exnref is now enabled by default (and may eventually be removed as well). If scala-cli hardcode options, we'll be in trouble when underlying runtime (node) removes options.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. nodeNeedsWasmFlag is now version-aware: private def nodeNeedsWasmFlag: Boolean = nodeMajorVersion.forall(_ < 25)

Copy link
Copy Markdown

@tanishiking tanishiking May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, --experimental-wasm-exnref being unnecessary in Node 26+ was just an example. The point was that options passed to Node shouldn't be hardcoded on the scala-cli side. (current implementation is fine since it doesn't add invalid options to node though)

Whether to detect the Node version and automatically add options should follow scala-cli's implementation policy. :) FYI @Gedochao


I was thinking about passing Node options from the scala-cli side like --node-args=--experimental-wasm-exnref,--experimental-wasm-imported-strings, but there's NODE_OPTIONS environment variable. Nevermind! 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add some options as implicit if user doesn't specify them and we know they are necessary to run the Wasm build. Just make sure the implicit stuff is logged, so that the user knows what's happening.

Comment thread modules/options/src/main/scala/scala/build/options/WasmOptions.scala Outdated
@tanishiking
Copy link
Copy Markdown

tanishiking commented May 12, 2026

Just left some drive by comments. (Also, I think adding deno and bun should be in a separate issue/PR).
Anyway, good work, thank you :)

@lostflydev lostflydev force-pushed the scala-wasm-support branch from 2d8329a to b37dae0 Compare May 15, 2026 15:45
@lostflydev
Copy link
Copy Markdown
Author

lostflydev commented May 15, 2026

Just left some drive by comments. (Also, I think adding deno and bun should be in a separate issue/PR). Anyway, good work, thank you :)

About the Deno/Bun scope: they share the exact same V8-based Wasm execution path as Node. Because of this, the implementation required minimal additions. Since supporting multiple runtimes was in the initial requirements, I included them here

@Gedochao wdyt?

@Gedochao
Copy link
Copy Markdown
Contributor

@lostflydev at a glance, I think it's fine to leave them in this PR.
I also mean to do a round of code review when I have some time, I'll try to do that after the weekend (and feel free to ping me again if I don't get to it).

@lostflydev lostflydev force-pushed the scala-wasm-support branch from 6d26adc to 0022b06 Compare May 27, 2026 13:42
Copy link
Copy Markdown
Contributor

@Gedochao Gedochao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments. Sorry to be so late to look at this.

// Check if Wasm mode is requested
if jsOpts.jsEmitWasm then {
val runtime = jsOpts.wasmRuntime
val esModule = true // Wasm backend uses ES modules
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth a logger.log about this being implicitly enabled if user hasn't enabled it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added logger.log("Wasm mode enabled: using ES module output on JS platform") right after the implicit assignment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh, I meant that we should log about it if the assignment is implicit... the added log currently prints regardless if the user passed the es module options or not, right?

Comment on lines +40 to +45
// When Wasm is enabled, force Platform.JS (Scala.js Wasm backend requires JS compilation)
val scalaOptions =
if (wasmEnabled)
ScalaOptions(platform = Some(Positioned.none(Platform.JS)))
else
ScalaOptions()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth a log about this being implicit.
Also, this might clash if a user explicitly passes --wasm with --platform native, is this detected anywhere?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a log in Run.scala when the Wasm path is entered ("Wasm mode enabled: using ES module output on JS platform")

}
}

if (TestUtil.fromPath("bun").isDefined)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the bun tests on the CI?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deno and Bun tests are conditionally guarded by TestUtil.fromPath — they run only when the runtime is on PATH. GitHub Actions runners don't include Deno or Bun, so these tests are skipped in CI

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should test those on CI

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1
there ought to be a way to set those up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note - this could be done as a follow-up, Deno and Bun support could be extracted to separate PRs (as suggested by @tanishiking) with tests done there.
I don't think we want those silently untested and subject to bitrot.

}
}

if (TestUtil.fromPath("deno").isDefined)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the deno tests run on the CI?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deno and Bun tests are conditionally guarded by TestUtil.fromPath — they run only when the runtime is on PATH. GitHub Actions runners don't include Deno or Bun, so these tests are skipped in CI

}
else if (emitWasm) {
// For WASM mode with ES modules, run node directly instead of NodeJSEnv.
// NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this conversation seems unresolved?


// Detects the major version of Node.js on PATH; cached for the JVM lifetime (lazy val).
// Returns None if node is not found or version cannot be parsed.
private lazy val nodeMajorVersion: Option[Int] =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likewise, unresolved?

nodeMajorVersion.foreach { v =>
if (v < 22) value(Left(new NodeVersionTooOldForWasmError(v)))
}
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add some options as implicit if user doesn't specify them and we know they are necessary to run the Wasm build. Just make sure the implicit stuff is logged, so that the user knows what's happening.

@Gedochao
Copy link
Copy Markdown
Contributor

The docs-tests sclicheck.GifTests.beforeAll(sclicheck.GifTests) failure is due to a change in the GitHub runner, I fixed it in aa91455
A rebase should help.

@Gedochao
Copy link
Copy Markdown
Contributor

Not necessarily in the scope of this PR, but - some docs for how to set up the Wasm runtimes and example snippets to run (maybe a dedicated guide) would be a great follow-up.

Implements scala-cli issue VirtusLab#3316: integrate WebAssembly with Scala CLI.

- `--wasm` CLI flag and `//> using wasm` directive to enable WASM output
- `--wasm-runtime <runtime>` option and `//> using wasmRuntime` directive
  Supported values: node (default), deno
- `--deno-version`, `--wasmtime-version`, `--wasmer-version` options
  and corresponding directives for pinning runtime versions

- **Node.js** (default): runs Scala.js WASM output with
  `--experimental-wasm-exnref` flag, requires Node.js >= 22
- **Deno**: runs Scala.js WASM output
…imes

- Move --wasm flag to dedicated Wasm help group with --help-wasm option
- Simplify wasmOptions parsing with fold/toRight pattern
- Add runtime validation with UnrecognizedWasmRuntimeError in directives
- Auto-enable WASM when wasmRuntime directive is set
- Update reference documentation

Code style: simplify denoNeedsWasmFlag, explicit runtime match cases, clean type annotation, scalfmt
…smRuntime bun)

  - Add BunNotFoundError with install hint
  - Add integration test for Bun (conditional on bun being on PATH)
  - Add actions/setup-node@v6 node-version:24 to all Linux integration test
    jobs: the default Node.js on ubuntu-24.04 runners is too old for Scala.js
    WASM GC (which requires Node.js >= 22). Matches docs-tests job which
    already pins node-version: 24
Node 24 still ships V8 12.x where wasm-exnref is gated behind --experimental-wasm-exnref; the flag only flips to default in V8 13.x (Node 25+). The previous nodeMajorVersion < 24 guard therefore left Node 24 (the version pinned in CI) without the flag, which made any Scala.js WASM code using exception bytecodes, runtime throws, JS interop or Scala 3 @main fail at runtime. Same reasoning applies to Deno (Deno 2.x = V8 12.x).

Until V8 13.x is the default everywhere, just always set the flag, there is no any overhead
…efactor into ScalaJsOptions

- Replace "WASM" with "Wasm" per WebAssembly spec: contraction, not acronym
- Fix nodeNeedsWasmFlag to be version-aware: only pass --experimental-wasm-exnref
  for Node < 25 (V8 12.x); Node 25+ has it enabled by default, Node 26+ may remove it
- Remove Node/Bun pre-flight version checks; let runtime fail naturally on old versions
- Remove else-if-emitWasm branch (not needed)
- Refactor WasmOptions into ScalaJsOptions: jsEmitWasm and wasmRuntime are now fields
  of ScalaJsOptions at both build and CLI layers; CLI flags are now --js-emit-wasm and
  --js-wasm-runtime under the Wasm help group; WasmOptions classes removed
- linkerConfig() now forces ESModule when jsEmitWasm=true
- Update all integration test CLI flags to --js-emit-wasm / --js-wasm-runtime
- Log when --experimental-wasm-exnref is injected in runJs (Node.js < 25)
- Log when Wasm mode enables ES module output in Run.scala
- Improve comment in Wasm.scala directive to reference AmbiguousPlatformError
  for --wasm + --platform native conflict detection
@lostflydev lostflydev force-pushed the scala-wasm-support branch from 004606c to 7899c1e Compare May 30, 2026 09:33
- runDeno: append --experimental-wasm-exnref to existing DENO_V8_FLAGS
  instead of silently replacing user-defined flags; log when flag is set
- ScalaJsOptions.linkerConfig: warn when Wasm overrides user-specified
  --js-module-kind (forced to ESModule by Wasm backend)
Lost during Run.scala restructure for Wasm: restores the original
case _: SbtFile => "" and case null => "" branches so SbtFile inputs
are properly filtered from scala.sources JVM property.
Comment on lines +14 to +17
object WasmRuntime {
case object Node extends WasmRuntime("node")
case object Deno extends WasmRuntime("deno")
case object Bun extends WasmRuntime("bun")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of them are JS runtimes (that embed Wasm engine).
maybe we wanna rename this to JSRuntime? and move into JS option group?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Integrate WASM with Scala CLI

6 participants